# -*- coding: utf-8 -*-

"""
    (c) 2020 - Copyright ...
    
    Authors:
        zPlus <zplus@peers.community>
    
    Notes:
        Useful documentation for SQLAlchemy ORM:
          https://docs.sqlalchemy.org/en/13/orm/tutorial.html
          https://docs.sqlalchemy.org/en/13/orm/basic_relationships.html
"""

import copy
import datetime
import functools
import logging
import json
import pagure.config
import pagure.lib.model
import pagure.lib.query
import random
import rdflib
import string
import urllib

from . import activitypub
from . import graph
from . import settings
from . import tasks

# The app URL defined in the Pagure configuration, eg. "https://example.org/"
# We need this for generating IDs.
APP_URL = pagure.config.config['APP_URL'].rstrip('/')
assert APP_URL and len(APP_URL) > 0, 'APP_URL missing from Pagure configuration.'

log = logging.getLogger(__name__)

def new_random_id(length=32):
    """
    Generate a random string to use as an Activity ID when a new one is
    created.
    """
    
    symbols = string.ascii_lowercase + string.digits
    return ''.join([ random.choice(symbols) for i in range(length) ])

def format_datetime(dt):
    """
    This function is used to format a datetime object into a string that is
    suitable for publishing in Activities.
    """
    
    return dt.replace(microsecond=0) \
             .replace(tzinfo=datetime.timezone.utc) \
             .isoformat()

def from_uri(pagure_db, uri):
    """
    In ActivityPub, objects' ID are URI and occasionally we need to retrieve an
    object from the (pagure) database given only its URI. If pagure were using
    a graph as a database, this would be trivial (just query "DESCRIBE <uri>").
    Also, the plugin does not mirror the entire pagure database into a graph;
    objects' documents (the JSON-LD that is returned when GETting) are created
    "on the fly" for every request. We could add the pagure ID to the JSON-LD
    object, but it would be very inelegant and hacky. We could add URIs to the
    pagure database, but it would be very brittle and would also require to
    change the pagure database schema. So we need a way, given only a URI, to
    reverse it and get the object in the database. This function does that.
    
    NOTES: This function is for *local* users only. It return an SQLAlchemy
        object given an ActivityPub URI.
    
    :param pagure_db: A session context of the pagure database.
    
    :param uri: The URI to reverse.
    """
    
    log.debug('from_uri: {}'.format(uri))
    
    # Fetch the JSON-LD document of the object.
    # Remember: we do not store the pagure objects in the graph, so we must
    # HTTP-GET the URI.
    object = activitypub.fetch(uri)
    
    uri = urllib.parse.urlsplit(uri)
    
    if object['type'] == 'Person':
        return pagure_db.query(Person) \
                        .filter(Person.user == object['preferredUsername']) \
                        .one_or_none()
    
    if object['type'] == 'Project':
        components = uri.path.lstrip('/').split('/')
        
        repo_username  = None
        repo_namespace = None
        repo_name      = None
        
        if len(components) == 1:
            repo_name      = components[0]
        if len(components) == 2:
            repo_namespace = components[0]
            repo_name      = components[1]
        if len(components) == 3:
            repo_username  = components[1]
            repo_name      = components[2]
        if len(components) == 4:
            repo_username  = components[1]
            repo_namespace = components[2]
            repo_name      = components[3]
        
        project = pagure_db.query(Projects).filter(Projects.name == repo_name)
        
        if repo_username:
            project = project.filter(Projects.is_fork == True) \
                             .filter(Projects.user.has(Person.user == repo_username))
        else:
            project = project.filter(Projects.is_fork == False)
        
        if repo_namespace:
            project = project.filter(Projects.namespace == repo_namespace)
        
        return project.one_or_none()
    
    if object['type'] == 'Ticket':
        # Ticket "context" contains a link to the Project URI
        project = from_uri(pagure_db, object['context'])
        
        # Ticket ID from the URI
        ticket_id = uri.path.rsplit('/', 1)[-1]
        
        return pagure_db.query(Ticket) \
                        .filter(Ticket.id == ticket_id,
                                Ticket.project_id == project.id) \
                        .one_or_none()
    
    if object['type'] == 'Note':
        if uri.path.startswith('/federation/ticket_comment/'):
            comment_id = uri.path.rsplit('/', 1)[-1]
            return pagure_db.query(TicketComment) \
                            .filter(TicketComment.id == comment_id) \
                            .one_or_none()
    
    # No object found
    return None

def from_local_uri(pagure_db, uri):
    # Check if the URI is an actual local object
    if not uri.startswith(APP_URL):
        return None
    
    return from_uri(pagure_db, uri)

def from_remote_uri(pagure_db, forgefed_graph, uri):
    # Check if the URI is an actual remote object
    if uri.startswith(APP_URL):
        return None
    
    # Get the URI of the local object that maps the remote object
    local_uri = forgefed_graph.value(
        predicate = rdflib.OWL.sameAs,
        object    = rdflib.URIRef(uri))
    
    if not local_uri:
        return None
    
    # Cast type URIRef() to string
    local_uri = str(local_uri)
    
    return from_uri(pagure_db, local_uri)

def test_or_set_remote_actor(pagure_db, forgefed_graph, uri):
    """
    We want to use the existing pagure UI to collaborate with remote users, for
    example to hold a conversation on a Ticket. Problem is, the pagure database
    model is built around *local* users that have IDs and relations with other
    schemas. So, we create a local mock user that represents a remote one, and
    call it user@domain.
    
    :param pagure_db: A session to the pagure database.
    :param forgefed_graph: A session to the forgefed graph.
    :param uri: The URI of the remote actor.
    """
    
    # Fetch JSONLD of the remote actor
    actor = activitypub.fetch(uri)
    webfinger = '{}@{}'.format(actor['preferredUsername'], urllib.parse.urlparse(uri).netloc)
    
    # This will create a new user in the Pagure database if it doesn't exist
    user = pagure.lib.query.set_up_user(
        session       = pagure_db,
        username      = webfinger.replace('/', '+'),
        fullname      = actor['name'],
        default_email = webfinger)
    
    # Return a Person object instead of the pagure User class
    person = pagure_db.query(Person) \
                      .filter(Person.id == user.id) \
                      .one_or_none()
    
    # Set a owl:sameAs link in the forgefed graph, so that we know that this
    # person is only used to represent a remote user.
    forgefed_graph.set((rdflib.URIRef(person.local_uri),
                        rdflib.OWL.sameAs,
                        rdflib.URIRef(uri)))
    
    return person

def test_or_set_remote_comment(pagure_db, forgefed_graph, uri):
    """
    This is the same as test_or_set_remote_actor() but for comments.
    
    :param pagure_db: A session to the pagure database.
    :param forgefed_graph: A session to the forgefed graph.
    :param uri: The URI of the remote Note.
    """
    
    # If the URI of the comment is a URI to the local instance, we don't
    # need to do anything because the pagure database already contains the
    # comment.
    if uri.startswith(APP_URL):
        return
    
    # Fetch JSONLD of the remote Note
    note = activitypub.fetch(uri)
    
    # Check if we already have a local object for this remote comment
    if (None, rdflib.OWL.sameAs, rdflib.URIRef(uri)) in forgefed_graph:
        log.debug('The note {} is already stored in the database. Will not create a new one.'.format(uri))
        return
    
    # Otherwise we create a local object in the pagure database for the remote Note...
    
    # If the "context" of the Note (which can be a Ticker or a MergeRequest) is
    # a local URL, it means somebody has created a Note for an object in our
    # database. This is the case for example when a remote user is contributing
    # a comment to a local project.
    if note['context'].startswith(APP_URL):
        
        # Get the database object
        context = from_local_uri(pagure_db, note['context'])
        
        # This is a new Note for a local Ticket
        if isinstance(context, Ticket):
            author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
            
            # Create the new comment to the ticket
            pagure.lib.query.add_issue_comment(
                session = pagure_db,
                issue = context,
                comment = note['content'],
                user = author.username)
        
        #if isinstance(context, MergeRequest):
            # TODO
        #    pass
    
    # If the "context" of the Note is not a local ticket or a local MR, this
    # note was created for a remote object. This is the case for example when a
    # user has sent a comment to a remote ticket, and we have received the
    # Activity because we are following that ticket.
    else:
        
        # Check if there is any local ticket or MR in the pagure database that
        # is used to track a remote object
        context = from_remote_uri(pagure_db, forgefed_graph, note['context'])
        
        # This is a new Note for a remote Ticket
        if isinstance(context, Ticket):
            author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
            
            # Create the new comment to the ticket
            pagure.lib.query.add_issue_comment(
                session = pagure_db,
                issue = context,
                comment = note['content'],
                user = author.username)

def action(func):
    """
    This function creates a decorator to be applied to the methods of class
    Actor. It represents an ActivityStream Action.
    The decorator will first execute the decorated function, and then schedule
    the delivery of the returned Activity.
    
    NOTE The function that is decorated with this decorator MUST return an
         Activity.
    
    :param func: The function to be decorated.
    """
    
    @functools.wraps(func)
    def decorator(self, *args, **kwargs):
        """
        Send an activity.
        By default, the Activity is sent to the Actor followers collection.
        To override this behavior it's possible to call the function like this:
            actor.follow(..., to=[], cc=[], bto=[], bcc=[])
        
        https://www.w3.org/TR/activitypub/#delivery
        """
        
        forgefed_graph = graph.Graph()
        
        # Create the activity from the Actor by executing the action (function)
        activity = func(self, *args, **kwargs)
        
        # Add publishing datetime
        #     - use UTC
        #     - remove microseconds, use HH:MM:SS only
        #     - add timezone info. There is also .astimezone() but it seems to
        #       return the wrong value when used with .utcnow(). Bug?
        #     - convert to ISO 8601 format
        activity['published'] = format_datetime(datetime.datetime.utcnow())
        
        # By default we send the activity to the as:followers collection
        activity['to'] = [ self.followers_uri ]
        
        # Create the list of recipients
        recipients = []
        
        # TODO Check if it's possible to simplify this code by using rdflib.Graph
        for field in [ 'to', 'cc', 'bto', 'bcc' ]:
            if field in kwargs:
                activity[field] = kwargs[field]
            
            if field in activity:
                if isinstance(activity[field], str):
                    recipients.append(activity[field])
                else:
                    recipients.extend(activity[field])
        
        # Save a copy of the Activity in the database and add it to the Actor's
        # OUTBOX before sending it
        forgefed_graph.parse(data=json.dumps(activity), format='json-ld')
        forgefed_graph.commit()
        forgefed_graph.add_collection_item(self.outbox_uri, activity['id'])
        
        # Now we are ready to POST to the remote actors
        
        # Before sending, remove bto and bcc according to spec.
        # https://www.w3.org/TR/activitypub/#client-to-server-interactions
        activity.pop('bto', None)
        activity.pop('bcc', None)
        
        # Stop here if there are no recipients.
        # https://www.w3.org/TR/activitypub/#h-note-8
        if len(recipients) == 0:
            log.debug('No recipients. Activity will not be sent.')
            return
        
        # Make sure the local actor has a GPG key before POSTing anything. The
        # remote Actor will use the key for versifying the Activity.
        forgefed_graph.test_or_set_key(self.local_uri, self.publickey_uri)
        
        # Create a new Celery task for each recipient. Activities are POSTed
        # individually because if one request fails we don't want to resend
        # the same Activity to *all* the recipients.
        for recipient in recipients:
            log.debug('Scheduling new activity: id={} recipient={}'.format(
                activity['id'], recipient))
            
            tasks.activity.post.delay(
                activity      = activity,
                recipient_uri = recipient,
                key_uri       = self.publickey_uri,
                depth         = settings.DELIVERY_DEPTH)
        
        forgefed_graph.disconnect()
    
    return decorator

class ActivityStreamObject:
    """
    An ActivityStrem Object. 
    """
    
    @property
    def uri(self):
        """
        The URI of this object.
        """
        
        remote_uri = self.remote_uri
        local_uri  = self.local_uri
        
        if remote_uri: return remote_uri
        if local_uri:  return local_uri
        return None
    
    @property
    def remote_uri(self):
        """
        The URI of the remote object if this object is only a local placeholder
        for a remote object.
        """
        
        g = graph.Graph()
        uri = g.value(rdflib.URIRef(self.local_uri), rdflib.OWL.sameAs)
        return str(uri) if uri else uri
    
    @property
    def is_remote(self):
        """
        Return True if this object is a local copy of a remote object. An example
        of such object would be a Ticket: a local copy is created such that users
        on the local instance can use the Pagure UI to interact with the remote
        Ticket.
        """
        
        return self.remote_uri != None
    
    @property
    def jsonld(self):
        return { '@context':  activitypub.jsonld_context }

class Actor(ActivityStreamObject):
    """
    An ActivityStream Actor.
    """
    
    def __repr__(self):
        raise Exception('Not implemented.')
    
    @property
    def inbox_uri(self):
        return self.local_uri + '/inbox'
    
    @property
    def outbox_uri(self):
        return self.local_uri + '/outbox'
    
    @property
    def followers_uri(self):
        return self.local_uri + '/followers'
    
    @property
    def following_uri(self):
        return self.local_uri + '/following'
    
    @property
    def publickey_uri(self):
        return self.local_uri + '/key.pub'
    
    @property
    def jsonld(self):
        return {
            **super().jsonld,
            'id':        self.local_uri,
            'inbox':     self.inbox_uri,
            'outbox':    self.outbox_uri,
            'followers': self.followers_uri,
            'following': self.following_uri,
            'publicKey': self.publickey_uri }
    
    @action
    def accept(self, object_uri, *args, **kwargs):
        """
        Accept Activity.
        
        :param object_uri: URI of the ActivityPub object that was accepted.
        """
        
        return {
            '@context': activitypub.jsonld_context,
            'id':       self.outbox_uri + '/' + new_random_id(),
            'type':     'Accept',
            'actor':    self.local_uri,
            'object':   object_uri,
            **kwargs }
    
    @action
    def create(self, object_uri, *args, **kwargs):
        """
        Create Activity.
        
        :param object_uri: URI of the ActivityPub object that was created.
        """
        
        return {
            '@context': activitypub.jsonld_context,
            'id':       self.outbox_uri + '/' + new_random_id(),
            'type':     'Create',
            'actor':    self.local_uri,
            'object':   object_uri }
    
    @action
    def follow(self, object_uri, *args, **kwargs):
        """
        Follow Activity.
        
        :param object_uri: URI of the ActivityPub object to follow (an Actor).
        """
        
        return {
            '@context': activitypub.jsonld_context,
            'id':       self.outbox_uri + '/' + new_random_id(),
            'type':     'Follow',
            'actor':    self.local_uri,
            'object':   object_uri }
    
    @action
    def offer(self, object, *args, **kwargs):
        """
        Offer Activity.
        
        :param object: Object to offer. Either a URI or a dictionary.
        """
        
        return {
            '@context': activitypub.jsonld_context,
            'id':       self.outbox_uri + '/' + new_random_id(),
            'type':     'Offer',
            'actor':    self.local_uri,
            'object':   object }
    
    @action
    def update(self, object, *args, **kwargs):
        """
        Update Activity.
        
        :param object: The object that was updated.
        """
        
        return {
            '@context': activitypub.jsonld_context,
            'id':       self.outbox_uri + '/' + new_random_id(),
            'type':     'Update',
            'actor':    self.local_uri,
            'object':   object }

class Person(pagure.lib.model.User, Actor):
    """
    An ActivityStream Person.
    """
    
    def __repr__(self):
        return {
            'class':     type(self).__name__,
            'type':      'Person',
            'id':        self.id,
            'name':      self.fullname,
            'actor_uri': self.uri }
    
    @property
    def local_uri(self):
        return '{}/{}'.format(APP_URL, self.url_path)
    
    @property
    def jsonld(self):
        return {
            **super().jsonld,
            'type': 'Person',
            'name': self.fullname,
            'preferredUsername': self.username }
    
    def handle_incoming_activity(self, activity):
        tasks.person.handle_incoming_activity.delay(self.id, activity)

class Repository(pagure.lib.model.Project, Actor):
    """
    A ForgeFed Repository.
    """
    
    def __repr__(self):
        return {
            'class':     type(self).__name__,
            'type':      'Repository',
            'id':        self.id,
            'name':      self.name,
            'namespace': self.namespace,
            'is_fork':   self.is_fork,
            'actor_uri': self.uri }
    
    @property
    def local_uri(self):
        return APP_URL + '/' + self.url_path + '.git'
    
    @property
    def jsonld(self):
        return {
            **super().jsonld,
            'type': 'Repository',
            'name': self.name,
            'team': None }
    
    def handle_incoming_activity(self, activity):
        tasks.repository.handle_incoming_activity.delay(self.id, activity)

class Projects(pagure.lib.model.Project, Actor):
    """
    A ForgeFed Project.
    """
    
    def __repr__(self):
        return {
            'class':     type(self).__name__,
            'type':      'Project',
            'id':        self.id,
            'name':      self.name,
            'namespace': self.namespace,
            'actor_uri': self.uri }
    
    @property
    def local_uri(self):
        return APP_URL + '/' + self.url_path
    
    @property
    def jsonld(self):
        return {
            **super().jsonld,
            'type': 'Project',
            'name': self.name,
            'preferredUsername': 'project/{}'.format(self.url_path) }
    
    def handle_incoming_activity(self, activity):
        tasks.project.handle_incoming_activity.delay(self.id, activity)

class Ticket(pagure.lib.model.Issue, ActivityStreamObject):
    @property
    def local_uri(self):
        return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
    
    @property
    def jsonld(self):
        return {
          **super().jsonld,
          'id': self.local_uri,
          'type': 'Ticket',
          'context': '{}/{}'.format(APP_URL, self.project.url_path),
          'attributedTo': '{}/{}'.format(APP_URL, self.user.url_path),
          'summary': self.title,
          'content': self.content,
          'mediaType': 'text/plain',
          'source': {
              'content': self.content,
              'mediaType': 'text/markdown; variant=CommonMark'
          },
          'assignedTo': None,
          'isResolved': False
        }

class TicketComment(pagure.lib.model.IssueComment, ActivityStreamObject):
    @property
    def local_uri(self):
        return '{}/federation/ticket_comment/{}'.format(APP_URL, self.id)
    
    @property
    def jsonld(self):
        # Find the "context" of the comment (ie. the URI of the Ticket it belongs to)
        ticket = self.issue #copy.deepcopy(self.issue)
        ticket.__class__ = Ticket
        context = ticket.uri
        
        return {
            **super().jsonld,
            'id': self.local_uri,
            'type': 'Note',
            'context': context,
            'attributedTo': APP_URL + '/' + self.user.url_path,
            'inReplyTo': None, # Pagure does not use nested comments
            'mediaType': 'text/plain',
            'content': self.comment,
            'source': {
                'mediaType': 'text/markdown; variant=Commonmark',
                'content': self.comment
            },
            'published': format_datetime(self.date_created)
        }

class MRComment(pagure.lib.model.IssueComment, ActivityStreamObject):
    """
    Merge Request Comment.
    """
    
    @property
    def local_uri(self):
        return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
    
    @property
    def jsonld(self):
        return {
          **super().jsonld,
        }
